package org.jspecial.tools;

import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.io.Writer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.commons.io.FileUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.Validate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static java.text.MessageFormat.format;

/**
 * Combines multiple JavaScript sources and their dependencies
 * ito a single source.
 *
 * @author Andrey Anisimov <hercules@inbox.ru>
 */
public class Combiner {
    private static final Logger LOG = LoggerFactory.getLogger(Combiner.class);
    private static final Pattern LOAD_PATTERN = Pattern.compile("[^\\w\\.]+load\\(\\s*[\\\"'](.*?)[\\\"']\\s*\\)\\s*;");
    private static final Pattern IMPORT_PATTERN = Pattern.compile("import\\s+([\\S]+)\\s*;");
    private static final String FILE_EXT = ".js";

    /**
     * JavaScript sources directory.
     * Usually the directory containing loader.js file.
     */
    private List<File> sourceDirs;

    /**
     * List of JavaScript soiurces.
     */
    private List<File> sourceFiles = new ArrayList<File>();

    /**
     * List of classes to be combined.
     */
    private List<String> classNames = new ArrayList<String>();

    /**
     * List of classes to be excluded together with their dependencies.
     */
    private List<String> classNamesExcluded = new ArrayList<String>();

    /**
     * JavaScript preprocessor.
     */
    private Preprocessor preprocessor = new DefaultPreprocessor();


    /**
     * Whether to append "use strict" at the beginning of the script.
     */
    private boolean useStrict;

    /**
     * Initializes the combiner object.
     *
     * @param classPath Paths to JS sources (separated by colon).
     */
    public Combiner(String classPath) {
        LOG.info("Init JavaScript combiner with class path {}", classPath);
        Validate.notEmpty(classPath, "classPath is empty");
        if (classPath.contains(":")) {
            sourceDirs = new LinkedList<>();
            for (String cp : classPath.split(":")) {
                File dir = new File(cp);
                Validate.isTrue(dir.isDirectory(), format("{0} is not a directory", cp));
                sourceDirs.add(dir);
            }
        } else {
            File dir = new File(classPath);
            Validate.isTrue(dir.isDirectory(), format("{0} is not a directory", classPath));
            sourceDirs = Arrays.asList(dir);
        }
    }

    /**
     * Sets JavaScript preprocessor for this combiner.
     *
     * @param preprocessor JavaScript preprocessor.
     */
    public void setPreprocessor(DefaultPreprocessor preprocessor) {
        this.preprocessor = preprocessor;
    }

    public boolean isUseStrict() {
        return useStrict;
    }

    public void setUseStrict(boolean useStrict) {
        this.useStrict = useStrict;
    }

    /**
     * Adds the specified source file to the list of files that will be processed.
     * Combiner will load all dependencies declared via <b>load</b> or <b>import</b>
     * statements from the given file and then write them to the output when calling
     * {@link #combine(java.io.Writer)} method.
     * <p/>
     * The sourceFile may be either HTML or JavaScript file containing
     * the entry point of the application. For example, it may be
     * some HTML file that loads all top-level components of the application,
     * or it may be an utility script which uses JavaScript classes somehow.
     *
     * @param sourceFile HTML or JavaScript file containing <b>load</b> or <b>import</b> calls.
     * @throws NullPointerException When sourceFile is null.
     */
    public void addSourceFile(File sourceFile) {
        LOG.info("Add source file " + sourceFile);
        if (null == sourceFile) {
            throw new NullPointerException("sourceFile parameter is null");
        }
        sourceFiles.add(sourceFile);
    }

    /**
     * Adds the specified class to the list of classes that will be combined.
     * Combiner will write the specified class including all its dependencies
     * to the output when calling {@link #combine(Writer)} method.
     *
     * @param className Fully-qualified class name.
     */
    public void addClass(String className) {
        LOG.info("Add class " + className);
        classNames.add(className);
    }

    /**
     * Exclude the specified class and all its dependencies
     * from combining.
     *
     * @param className Fully-qualified class name.
     */
    public void excludeClass(String className) {
        LOG.info("Exclude class " + className);
        classNamesExcluded.add(className);
    }

    /**
     * Combines all sources into the string.
     *
     * @return Combined script.
     * @throws IOException Some i/o error.
     */
    public String combine() throws IOException {
        StringWriter writer = new StringWriter();
        combine(writer);
        return writer.toString();
    }

    /**
     * Combines all sources and writes the result into the output.
     *
     * @param out Combining output.
     * @throws IOException Some i/o error.
     */
    public void combine(Writer out) throws IOException {
        LOG.info("Started combining sources");
        PrintWriter writer = new PrintWriter(out);
        if (useStrict) {
            writer.println("\"use strict\";");
        }
        Collection<String> excluded = classesExcluded();
        CombinerProcessor processor = new CombinerProcessor(out, preprocessor, excluded);
        for (File file : sourceFiles) {
            processSourceFile(file, processor);
        }
        for (String className : classNames) {
            processClass(className, processor);
        }
    }

    /**
     * Loads all dependencies from the given source file and writes them to the output
     * <i>without</i> writing the source code itself.
     *
     * @param file The HTML or JavaScript source file.
     * @return Contents of the given file.
     * @throws IOException Some i/o error.
     */
    private String processSourceFile(File file, ScriptProcessor scriptProcessor) throws IOException {
        LOG.info("Processing source file " + file);
        if (!file.isFile()) {
            throw new IOException(file + " is not a file");
        }
        if (!file.canRead()) {
            throw new IOException(file + " cannot be read");
        }
        String contents = FileUtils.readFileToString(file);
        for (Pattern p : Arrays.asList(LOAD_PATTERN, IMPORT_PATTERN)) {
            Matcher m = p.matcher(contents);
            while (m.find()) {
                String className = m.group(1);
                processClass(className, scriptProcessor);
            }
        }
        return contents;
    }

    /**
     * Loads all class dependencies and writes them to the output
     * <i>including<i> the class itself.
     *
     * @param className       Fully-qualified class name.
     * @param scriptProcessor A delegate object to process scripts.
     * @throws java.io.IOException Some i/o error
     */
    private void processClass(String className, ScriptProcessor scriptProcessor) throws IOException {
        if (!scriptProcessor.processed(className)) {
            scriptProcessor.markProcessed(className);
            LOG.info("Processing class " + className);
            boolean processed = false;
            for (File sourceDir : sourceDirs) {
                File file = new File(sourceDir.getAbsolutePath() + File.separator + className.replace('.', File.separatorChar) + FILE_EXT);
                if (file.exists()) {
                    String script = processSourceFile(file, scriptProcessor);
                    scriptProcessor.processScript(className, script);
                    processed = true;
                    break;
                }
            }
            Validate.isTrue(processed, format(
                    "Class {0} not found in classpath {1}",
                    className,
                    StringUtils.join(sourceDirs, ":")));
        }
    }

    private Collection<String> classesExcluded() throws IOException {
        Collection<String> excluded = new HashSet<>();
        ClassNameCollectingProcessor processor = new ClassNameCollectingProcessor();
        for (String className : classNamesExcluded) {
            processClass(className, processor);
            excluded.addAll(processor.getClasses());
        }
        return excluded;
    }

    private static interface ScriptProcessor {
        boolean processed(String className);

        void markProcessed(String className);

        void processScript(String className, String script) throws IOException;
    }

    private static class ClassNameCollectingProcessor implements ScriptProcessor {
        /**
         * Collection of processed JavaScript classes.
         */
        private final Set<String> classes = new LinkedHashSet<>();

        @Override
        public boolean processed(String className) {
            return classes.contains(className);
        }

        @Override
        public void markProcessed(String className) {
            classes.add(className);
        }

        @Override
        public void processScript(String className, String script) throws IOException {
            // Stub
        }

        public Set<String> getClasses() {
            return classes;
        }
    }

    private static class CombinerProcessor extends ClassNameCollectingProcessor {
        private final Preprocessor preprocessor;
        private final Writer out;
        private final Collection<String> excluded;

        private CombinerProcessor(Writer out, Preprocessor preprocessor, Collection<String> excluded) {
            this.out = out;
            this.preprocessor = preprocessor;
            this.excluded = excluded;
        }

        @Override
        public void processScript(String className, String script) throws IOException {
            super.processScript(className, script);
            if (!excluded.contains(className)) {
                out.write(preprocessor.process(script, className));
            } else {
                LOG.info("Class {} skipped", className);
            }
        }
    }
}
